unknown option --help type -h to see help


NixOS on my server

Updating over wireguard without cutting the branch you're sitting on, migrating services


πŸ—“οΈ Created: 2026-05-09 | Modified: 1970-01-01

❄️

I wish to try out nix on server infrastructure, my public server is the least critical server, as it mainly serves as my playground. I will be deploying nix the nix way, to get the full benefits. This means transition all my services to being fully declared with nix.

My services:

and a bunch of regular server setup built up over the years, you'll be supprised how many small things you've set up over the years

Deployment with automatic rollback if unreachable

Problem

This server is only available over wireguard, when running nixos-rebuild switch with the wireguard address as --target-host, it's really easy to set some config option that makes the system unreachable.

Simple native solution

Recovering is easy, there's a command to switch back to the booted system.

[root@node5:~]# ls -lah /run/*system
lrwxrwxrwx 1 root root 85 May  2 13:31 /run/booted-system -> /nix/store/ksj77alpblymmnhfyzb3r5vlb4d7qhr8-nixos-system-node5-25.11.20260415.1766437
lrwxrwxrwx 1 root root 85 May  2 13:31 /run/current-system -> /nix/store/ksj77alpblymmnhfyzb3r5vlb4d7qhr8-nixos-system-node5-25.11.20260415.1766437
[root@node5:~]# /run/booted-system/bin/switch-to-configuration
Usage: switch-to-configuration [check|switch|boot|test|dry-activate]
check:        run pre-switch checks and exit
switch:       make the configuration the boot default and activate now
boot:         make the configuration the boot default
test:         activate the configuration, but don't make it the boot default
dry-activate: show what would be done if this configuration were activated

Now it would be nice if there was an automated rollback in case the system became unreachable. This could be as simple as: run a root tmux with

sleep 300 && /run/booted-system/bin/switch-to-configuration

However what does it do if an activation take more than 5 minutes, what if you forget? Plus i even had once where the wireguard service didn't come up by it self again. It would be nicer with a purpose build tool.

deploy-rs

deploy-rs - github.com seems to fit the bill with it's ✨Magic Rollback✨

"There is a built-in feature to prevent you making changes that might render your machine unconnectable or unusuable, which works by connecting to the machine after profile activation to confirm the machine is still available, and instructing the target node to automatically roll back if it is not confirmed" - deploy-rs readme

Here's a nice deploy-rs setup guide - crystalwobsite.gay

Test server

Let's try it out on a test server

diff --git a/flake.nix b/flake.nix
index a056d72..b47d632 100644
--- a/flake.nix
+++ b/flake.nix
@@ -23,9 +23,11 @@
     };
␠
     node5-nvim.url = "git+https://git.node5.net/nix/nvim";
+
+    deploy-rs.url = "github:serokell/deploy-rs";
   };
␠
-  outputs = { self, nixpkgs, nixpkgs-unstable, home-manager, ... } @ inputs:
+  outputs = { self, nixpkgs, nixpkgs-unstable, home-manager, deploy-rs, ... } @ inputs:
     let
       inherit (self) outputs;
       system = "x86_64-linux";
@@ -73,6 +75,32 @@
           ];
         };
␠
+        node5-test = nixpkgs.lib.nixosSystem {
+          specialArgs = {inherit inputs unstable; };
+          modules = [
+            ./modules/hosts/node5-test/configuration.nix
+            ./modules/common.nix # nixos stuff I want on all machines
+          ];
+        };
+
       };
+
+      deploy = {
+        nodes = {
+          node5-test = {
+            hostname = "192.168.1.63";
+            sshUser = "root";
+            profiles = {
+              system = {
+                user = "root";
+                path =
+                  deploy-rs.lib.${system}.activate.nixos
+                  self.nixosConfigurations.node5-test;
+              };
+            };
+          };
+        };
+      };
+
     };
 }
❯ deploy .

πŸš€ ℹ️ [deploy] [INFO] Running checks for flake in .
warning: Git tree '/home/user/dot-files' is dirty
warning: unknown flake output 'deploy'
πŸš€ ℹ️ [deploy] [INFO] Evaluating flake in .
warning: Git tree '/home/user/dot-files' is dirty
πŸš€ ℹ️ [deploy] [INFO] The following profiles are going to be deployed:
[node5-test.system]
user = "root"
ssh_user = "root"
path = "/nix/store/z8c3vc2689lbcvplhs42iqzbbb7x7k9s-activatable-nixos-system-node5-25.11.20260415.1766437"
hostname = "192.168.1.63"
ssh_opts = []

πŸš€ ℹ️ [deploy] [INFO] Building profile `system` for node `node5-test`
πŸš€ ℹ️ [deploy] [INFO] Copying profile `system` to node `node5-test`
πŸš€ ℹ️ [deploy] [INFO] Activating profile `system` for node `node5-test`
πŸš€ ℹ️ [deploy] [INFO] Creating activation waiter
πŸ‘€ ℹ️ [wait] [INFO] Waiting for confirmation event...
⭐ ℹ️ [activate] [INFO] Activating profile
activating the configuration...
setting up /etc...
reloading user units for user...
reloading user units for root...
restarting sysinit-reactivation.target
the following new units were started: NetworkManager-dispatcher.service
⭐ ℹ️ [activate] [INFO] Activation succeeded!
⭐ ℹ️ [activate] [INFO] Magic rollback is enabled, setting up confirmation hook...
πŸ‘€ ℹ️ [wait] [INFO] Found canary file, done waiting!
⭐ ℹ️ [activate] [INFO] Waiting for confirmation event...
πŸš€ ℹ️ [deploy] [INFO] Success activating, attempting to confirm activation
πŸš€ ℹ️ [deploy] [INFO] Deployment confirmed.

 οŒ“ ξ‚Ό  ~/dot-files ξ‚Ό  master *4 +6 !5 ──────────────────────────────────── ο‰’ 52s ξ‚Ί οŒ“ impure ξ‚Ί 21:36:55

It deploys a working config successfully, now let's change the config such that we no longer have SSH access to the server

-  networking.firewall.allowedTCPPorts = [ 22 ];
❯ deploy .
πŸš€ ℹ️ [deploy] [INFO] Running checks for flake in .
warning: Git tree '/home/user/dot-files' is dirty
warning: unknown flake output 'deploy'
πŸš€ ℹ️ [deploy] [INFO] Evaluating flake in .
warning: Git tree '/home/user/dot-files' is dirty
πŸš€ ℹ️ [deploy] [INFO] The following profiles are going to be deployed:
[node5-test.system]
user = "root"
ssh_user = "root"
path = "/nix/store/2dfsx5blqqib25ir0v32azqn2g49d267-activatable-nixos-system-node5-25.11.20260415.1766437"
hostname = "192.168.1.63"
ssh_opts = []

πŸš€ ℹ️ [deploy] [INFO] Building profile `system` for node `node5-test`
πŸš€ ℹ️ [deploy] [INFO] Copying profile `system` to node `node5-test`
πŸš€ ℹ️ [deploy] [INFO] Activating profile `system` for node `node5-test`
πŸš€ ℹ️ [deploy] [INFO] Creating activation waiter
πŸ‘€ ℹ️ [wait] [INFO] Waiting for confirmation event...
⭐ ℹ️ [activate] [INFO] Activating profile
activating the configuration...
setting up /etc...
reloading user units for user...
reloading user units for root...
restarting sysinit-reactivation.target
reloading the following units: nftables.service
the following new units were started: NetworkManager-dispatcher.service
⭐ ℹ️ [activate] [INFO] Activation succeeded!
⭐ ℹ️ [activate] [INFO] Magic rollback is enabled, setting up confirmation hook...
πŸ‘€ ℹ️ [wait] [INFO] Found canary file, done waiting!
⭐ ℹ️ [activate] [INFO] Waiting for confirmation event...
πŸš€ ℹ️ [deploy] [INFO] Success activating, attempting to confirm activation
⭐ ⚠️ [activate] [WARN] De-activating due to error
switching profile from version 21 to 20
⭐ ⚠️ [activate] [WARN] Removing generation by ID 21
removing profile version 21
⭐ ℹ️ [activate] [INFO] Attempting to re-activate the last generation
activating the configuration...
setting up /etc...
reloading user units for user...
reloading user units for root...
restarting sysinit-reactivation.target
reloading the following units: nftables.service
the following new units were started: NetworkManager-dispatcher.service
⭐ ❌ [activate] [ERROR] Failed to get activation confirmation: Error waiting for confirmation event: Timeout elapsed for confirmation

thread 'tokio-runtime-worker' panicked at /build/source/src/deploy.rs:490:41:
called `Result::unwrap()` on an `Err` value: SSHActivateExit(Some(1))
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
πŸš€ ℹ️ [deploy] [INFO] Deployment confirmed.
πŸš€ ❌ [deploy] [ERROR] Activating over SSH resulted in a bad exit code: RecvError(())
πŸš€ ℹ️ [deploy] [INFO] Revoking previous deploys
πŸš€ ❌ [deploy] [ERROR] Deployment to node node5-test failed, rolled back to previous generation

 οŒ“ ξ‚Ό  ~/dot-files ξ‚Ό  master *4 +6 !5 ───────────────────────────────────────────── βœ” 1|0 ξ‚Ί ο‰’ 1m 29s ξ‚Ί οŒ“ impure ξ‚Ί 21:38:27

Success!

πŸš€ ❌ [deploy] [ERROR] Deployment to node node5-test failed, rolled back to previous generation

Prod server wireguard

Cool, let's ship it to prod 🚒

πŸš€ ℹ️ [deploy] [INFO] Running checks for flake in /home/user/dot-files/
warning: Git tree '/home/user/dot-files' is dirty
warning: unknown flake output 'deploy'
πŸš€ ℹ️ [deploy] [INFO] Evaluating flake in /home/user/dot-files/
warning: Git tree '/home/user/dot-files' is dirty
πŸš€ ℹ️ [deploy] [INFO] The following profiles are going to be deployed:
[node5-test.system]
user = "root"
ssh_user = "root"
path = "/nix/store/1sqnzii8yiv42v2ci4m2cnx34qc6mima-activatable-nixos-system-node5-test-25.11.20260415.1766437"
hostname = "10.10.41.1"
ssh_opts = []

πŸš€ ℹ️ [deploy] [INFO] Building profile `system` for node `node5-test`
πŸš€ ℹ️ [deploy] [INFO] Copying profile `system` to node `node5-test`
πŸš€ ℹ️ [deploy] [INFO] Activating profile `system` for node `node5-test`
πŸš€ ℹ️ [deploy] [INFO] Creating activation waiter
πŸš€ ℹ️ [deploy] [INFO] Success activating, attempting to confirm activation
⭐ ℹ️ [activate] [INFO] Activating profile
πŸš€ ℹ️ [deploy] [INFO] Deployment confirmed.
stopping the following units: wg-quick-wg0.service

Bollocks, it still takes down the wireguard service as part of the deployment, and doesn't recover automatically. Solution: switch from wg-quick to native wireguard.

diff --git a/modules/hosts/node5-test/wireguard.nix b/modules/hosts/node5-test/wireguard.nix
index 288f7ae..ad86695 100644
--- a/modules/hosts/node5-test/wireguard.nix
+++ b/modules/hosts/node5-test/wireguard.nix
@@ -7,18 +7,24 @@ in
     allowedUDPPorts = [ listenPort ];
     interfaces."wg0".allowedTCPPorts = [ 22 ]; # SSH from personal systems
   };
-  networking.wg-quick.interfaces = {
+  networking.wireguard.interfaces = {
     wg0 = {
-      address = [ "10.10.41.1/24" ];
+      ips = [ "10.10.41.1/24" ];
       privateKeyFile = "/etc/secrets/wireguard/privatekey";
       listenPort = listenPort;
       peers = [
         {
-          # T480s
+          name = "T480s";
           publicKey = "YYjWG9lD4zkjNkjMYH4CfIac1sqsWZknWFh6d4OxmnM=";
           presharedKeyFile = "/etc/secrets/wireguard/t480s_presharedkey";
-          allowedIPs = [ "10.10.41.110/24" ];
+          allowedIPs = [ "10.10.41.110/32" ];
         }
       ];
     };
   };

Note: Allowed IP must be /32, /24 will cause it to silently fail


Front page

Nginx serving static files

Derivations

Derivations is the way to copy things to the nix store, it's done with the command stdenv.mkDerivation, which consists

  1. Unpack: handles the preparation of the build environment (e.g. extracting archives, touching files, etc.);
  2. Patch: handles changes to the underlying source code (e.g. patching bugs, adapting the source code to work in a Nix environment, etc.);
  3. Configure: handles the configuration of the build environment, for example detecting system capabilities and setting build parameters;
  4. Build: compiles the source code into binaries, bytecode, or otherwise a distributable form of the source code;
  5. Check: performs any tests on the compiled package, for example the package's test suite;
  6. Install: copies the build artefacts to the output directory, handling any needed changes (e.g. directory structure reorganisation);
  7. Fixup: process the output artefacts to work in a Nix environment (e.g.: strip binaries, override ELF paths, handle dynamic library linking, etc.);
  8. Install Check: performs any tests on the final output, essentially acting as a integration test into the Nix environment;
  9. Dist: creates distribution archives (rarely used).

Read more: source - wiki.nixos.org

{ pkgs, lib, ... }:
let
  node5Static = pkgs.stdenv.mkDerivation {
    name = "node5-static-site";
    src = ./files;
    postInstall = ''
      mkdir $out
      cp -av ./* $out/
      '';
  };
in
{

  networking.firewall.allowedTCPPorts = [ 80 443 ];
  services.nginx.enable = true;
  services.nginx.virtualHosts."node5.net" = {
    forceSSL = true;
    enableACME = true;
    root = "${node5Static}";
  };
  security.acme = {
    acceptTerms = true;
    defaults.email = "lets.encrypt@node5.net";
  };
}

Blog

Packaging as binary

Following this example Python - wiki.nixos.org and adding a bit of meta data, i can now build the application as a command :)

This means you can run it with nix run git+https://git.node5.net/blog/blog.node5.net_flask, or add it to environment.systemPackages with

flake.nix

node5-blog.url = "git+https://git.node5.net/blog/blog.node5.net_flask";

configuration.nix

environment.systemPackages = with pkgs; [
  inputs.node5-blog.packages.x86_64-linux.default
]
{
  description = "A basic flake using pyproject.toml project metadata";

  inputs = {
    pyproject-nix = {
      url = "github:nix-community/pyproject.nix";
      inputs.nixpkgs.follows = "nixpkgs";
    };
  };

  outputs = { nixpkgs, pyproject-nix, ... }:
    let
      inherit (nixpkgs) lib;

      project = pyproject-nix.lib.project.loadPyproject {
        # Read & unmarshal pyproject.toml relative to this project root.
        # projectRoot is also used to set `src` for renderers such as buildPythonPackage.
        projectRoot = ./.;
      };

      # This example is only using x86_64-linux
      pkgs = nixpkgs.legacyPackages.x86_64-linux;

      python = pkgs.python3;

    in
    {
      # Build our package using `buildPythonPackage
      packages.x86_64-linux.default =
        let
          # Returns an attribute set that can be passed to `buildPythonPackage`.
          attrs = project.renderers.buildPythonPackage { inherit python; };
        in
        # Pass attributes to buildPythonPackage.
        # Here is a good spot to add on any missing or custom attributes.
        python.pkgs.buildPythonPackage (attrs // {
            meta = {
            description = "Blog backend for blog.node5.net";
            homepage = "https://blog.node5.net/Blog%20meta/";
            changelog = "https://git.node5.net/blog/blog.node5.net_flask/log/";
            mainProgram = "blog-node5";
            };
        });
    };
}
result
β”œβ”€β”€ bin
β”‚   └── blog-node5
β”œβ”€β”€ lib
β”‚   └── python3.13
β”‚       └── site-packages
β”‚           β”œβ”€β”€ __pycache__
β”‚           β”‚   β”œβ”€β”€ article.cpython-313.opt-1.pyc
β”‚           β”‚   β”œβ”€β”€ article.cpython-313.pyc
β”‚           β”‚   β”œβ”€β”€ blog_node5_net.cpython-313.opt-1.pyc
β”‚           β”‚   β”œβ”€β”€ blog_node5_net.cpython-313.pyc
β”‚           β”‚   β”œβ”€β”€ db_handler.cpython-313.opt-1.pyc
β”‚           β”‚   β”œβ”€β”€ db_handler.cpython-313.pyc
β”‚           β”‚   β”œβ”€β”€ telegram_handler.cpython-313.opt-1.pyc
β”‚           β”‚   └── telegram_handler.cpython-313.pyc
β”‚           β”œβ”€β”€ blog_node5_net-0.1.0.dist-info
β”‚           β”‚   β”œβ”€β”€ entry_points.txt
β”‚           β”‚   β”œβ”€β”€ METADATA
β”‚           β”‚   β”œβ”€β”€ RECORD
β”‚           β”‚   β”œβ”€β”€ top_level.txt
β”‚           β”‚   └── WHEEL
β”‚           β”œβ”€β”€ article.py
β”‚           β”œβ”€β”€ blog_node5_net.py
β”‚           β”œβ”€β”€ db_handler.py
β”‚           └── telegram_handler.py
└── nix-support
    └── propagated-build-inputs

cat result/nix-support/propagated-build-inputs

/nix/store/10hk7srr12wgp2hqm5lai0xxr69m76b7-python3.13-flask-3.1.2 /nix/store/jl0mxihyizv77l66mzbvmv49iiri72sd-python3.13-pyyaml-6.0.3 /nix/store/pkj9yz58kijfwyg4c0xpwc2dlwwswr6s-python3.13-markdown-3.10.2 /nix/store/6svr8x0lzmsn8d70asdc5qns35273216-python3.13-python-telegram-bot-22.7 /nix/store/6snki2zk3rmh13wwi07g3x79a1rr032m-python3.13-pygments-2.20.0 /nix/store/rfhv4bxzg6aqv7ll7d2g3fx7vdj63ks3-python3.13-tabulate-0.10.0 /nix/store/0r6k8xa2kgqyp3r4v2w7yrb80ma2iawm-python3-3.13.12 

or listed out

/nix/store/10hk7srr12wgp2hqm5lai0xxr69m76b7-python3.13-flask-3.1.2
/nix/store/jl0mxihyizv77l66mzbvmv49iiri72sd-python3.13-pyyaml-6.0.3
/nix/store/pkj9yz58kijfwyg4c0xpwc2dlwwswr6s-python3.13-markdown-3.10.2
/nix/store/6svr8x0lzmsn8d70asdc5qns35273216-python3.13-python-telegram-bot-22.7
/nix/store/6snki2zk3rmh13wwi07g3x79a1rr032m-python3.13-pygments-2.20.0
/nix/store/rfhv4bxzg6aqv7ll7d2g3fx7vdj63ks3-python3.13-tabulate-0.10.0
/nix/store/0r6k8xa2kgqyp3r4v2w7yrb80ma2iawm-python3-3.13.12

Prod UWSGI

Following the "Official NixOS Wiki" page for UWSGI gives us an example of how to host an application with UWSGI. It hinges on pythonPath to function, this is a list of paths to python packages.

nix-repl> inputs.nixpkgs.legacyPackages.aarch64-linux.oncall.pythonPath
"/nix/store/w9v0xf1jg5agcxrn8fzl3nxqsrrbxam4-python3-3.13.12/lib/python3.13/site-packages:/nix/store/vwql81c83bdidrnfbf91477i3izhj469-python3.13-beaker-1.13.0/lib/python3.13/site-packages:/nix/store/h7q4mj3p6cc1zdpg0hf2rlf5x7bqjfnx-python3.13-falcon-4.0.2/lib/python3.13/site-packages:/nix/store/wvhh1s7fdkslx02jplcwfyrqhhzp6s84-python3.13-falcon-cors-1.1.7/lib/python3.13/site-packages:/nix/store/9rnq594bh5wzqw1k6npykgxmhgkyvbrv-python3.13-gevent-25.5.1/lib/python3.13/site-packages:/nix/store/hz7x8s6mxmbnlbkxf5h4717hr8ing3g6-python3.13-gunicorn-23.0.0/lib/python3.13/site-packages:/nix/store/jb72aqjm73c45j1zch7647rky6wqmfbf-python3.13-icalendar-6.3.2/lib/python3.13/site-packages:/nix/store/3hqasdzwya752k4lvclmkvzqymj93yqd-python3.13-irisclient-1.2.0/lib/python3.13/site-packages:/nix/store/wrrd7848134g5fxml6rhyy2gy1pszm80-python3.13-jinja2-3.1.6/lib/python3.13/site-packages:/nix/store/2r3gb5z2h6vg4i7jppwxyvwjfj9m7aa9-python3.13-phonenumbers-9.0.10/lib/python3.13/site-packages:/nix/store/v5lryy9ip42l4j8nqb0ai85gs0ps2h8v-python3.13-pymysql-1.1.1/lib/python3.13/site-packages:/nix/store/8llwrni08jgbai4h1gzid2j951zm008d-python3.13-python-ldap-3.4.5/lib/python3.13/site-packages:/nix/store/bkkhkgp46vvxp3cdcr0kkhga0wipqr7g-python3.13-pytz-2025.2/lib/python3.13/site-packages:/nix/store/rvq6x8wh9xrf26r0ar60zmc44g7akhrq-python3.13-pyyaml-6.0.3/lib/python3.13/site-packages:/nix/store/xfq8fkgpm8a5v6sqacs0s0h776547df5-python3.13-ujson-5.10.0/lib/python3.13/site-packages:/nix/store/9bvzi4y2xqk6v6zsqjipx3il1n5q7wyq-python3.13-webassets-2.0/lib/python3.13/site-packages:/nix/store/c0r4ikngyy6b6d11vgg69dp23amn84r6-python3.13-sqlalchemy-2.0.44/lib/python3.13/site-packages:/nix/store/r09fvwifl9hbk07z0v8w28lwj08fq49w-python3.13-pycrypto-3.23.0/lib/python3.13/site-packages:/nix/store/6w5ykz0ql7iq3kl7z0bzj91mfzg2zv88-python3.13-cryptography-46.0.7/lib/python3.13/site-packages:/nix/store/jxnid1fl09j140mb7galy581p0yl8n32-python3.13-greenlet-3.2.3/lib/python3.13/site-packages:/nix/store/zq2wjvr0ggry3l1c6i429yn6mlj06w3g-python3.13-typing-extensions-4.15.0/lib/python3.13/site-packages:/nix/store/sbn0djkjz9y04pi0kqdrr8gr2ch2yqpf-python3.13-pycryptodome-3.23.0/lib/python3.13/site-packages:/nix/store/qck3biyzm0dllzqbipcrrzq8833dgv9w-python3.13-cffi-2.0.0/lib/python3.13/site-packages:/nix/store/9a6whjkar8lgcx4r7s1raghy8cx2qmvi-python3.13-pycparser-2.23/lib/python3.13/site-packages:/nix/store/zlyab7h640ndms7j1hddqg21qffjyg9h-python3.13-importlib-metadata-8.7.0/lib/python3.13/site-packages:/nix/store/01c584pblchs1mb8a8x8qv7nrqqmnj34-python3.13-zope-event-5.0/lib/python3.13/site-packages:/nix/store/kfqnq2yja6a8mpviddf0hwwbyj02lgrh-python3.13-zope-interface-7.2/lib/python3.13/site-packages:/nix/store/4yx6g5cmf7qdz3ma1kxyihh2qax5wiyl-python3.13-toml-0.10.2/lib/python3.13/site-packages:/nix/store/mbp694ghx6mxq688ki82zy0sh81f32xp-python3.13-zipp-3.23.0/lib/python3.13/site-packages:/nix/store/wq0qqmf5hb2mvihhjlqkn5f78df7z764-python3.13-packaging-25.0/lib/python3.13/site-packages:/nix/store/jqpbhxhfc5rn2s7vg2d0k27xgnay5w99-python3.13-python-dateutil-2.9.0.post0/lib/python3.13/site-packages:/nix/store/mmpgfjlkjmnmck321z2l02794hz4mh26-python3.13-tzdata-2025.2/lib/python3.13/site-packages:/nix/store/z120cd67469z5n44cpdyp4928kz5lmm5-python3.13-six-1.17.0/lib/python3.13/site-packages:/nix/store/4iaiqf1rgap1yycfn2hypk8z461b0jfk-python3.13-requests-2.33.1/lib/python3.13/site-packages:/nix/store/8qfwrsrj394j1fp4mvjdb6b1n41sd8n5-python3.13-certifi-2025.07.14/lib/python3.13/site-packages:/nix/store/r74s0kvqgk32yx74l4fi4fgvghlli0g5-python3.13-charset-normalizer-3.4.3/lib/python3.13/site-packages:/nix/store/ifjkkxadz3m6yfj8ldnbf732fh9h0xm8-python3.13-idna-3.11/lib/python3.13/site-packages:/nix/store/3yhphqykh9vhdaks6r68g1lkj8gs5b79-python3.13-urllib3-2.5.0/lib/python3.13/site-packages:/nix/store/w0x1yqwy7sgagkxs1kjxdd2myvw28gn6-python3.13-markupsafe-3.0.3/lib/python3.13/site-packages:/nix/store/q56lcwiczk03wvkhnrmgcqrgmrxc0y0p-python3.13-pyasn1-0.6.2/lib/python3.13/site-packages:/nix/store/89al2y3ivn1fc8r86zhsd8q442y5izsz-python3.13-pyasn1-modules-0.4.2/lib/python3.13/site-packages:/nix/store/gaj10yk8vl094knp2s4ah10z0xqfgx8l-oncall-0-unstable-2025-04-15/lib/python3.13/site-packages"

nix-repl> inputs.node5-blog.outputs.packages.x86_64-linux.default.pythonPath
[ ]

the package from the UWSGI example exports a python path, mine does not, exmining the package from the UWSGI example, it defines the pythonPath by hand

pythonPath = "${python3.pkgs.makePythonPath dependencies}:${oncall}/${python3.sitePackages}";

Modifying my flake to export pythonPath aswell, by moving the pkg build to let in, and exposing it in the output.

let
  ...

  pkg = python.pkgs.buildPythonPackage (attrs // {
      meta = {
        description = "Blog backend for blog.node5.net";
        homepage = "https://blog.node5.net/Blog%20meta/";
        changelog = "https://git.node5.net/blog/blog.node5.net_flask/log/";
        mainProgram = "blog-node5";
      };
    });
in
{
  packages.x86_64-linux.default = pkg;
  pythonPath = "${python.pkgs.makePythonPath attrs.dependencies}:${pkg}/${python.sitePackages}";
}

Success

nix-repl> outputs.pythonPath
"/nix/store/0r6k8xa2kgqyp3r4v2w7yrb80ma2iawm-python3-3.13.12/lib/python3.13/site-packages:/nix/store/10hk7srr12wgp2hqm5lai0xxr69m76b7-python3.13-flask-3.1.2/lib/python3.13/site-packages:/nix/store/jl0mxihyizv77l66mzbvmv49iiri72sd-python3.13-pyyaml-6.0.3/lib/python3.13/site-packages:/nix/store/pkj9yz58kijfwyg4c0xpwc2dlwwswr6s-python3.13-markdown-3.10.2/lib/python3.13/site-packages:/nix/store/6svr8x0lzmsn8d70asdc5qns35273216-python3.13-python-telegram-bot-22.7/lib/python3.13/site-packages:/nix/store/6snki2zk3rmh13wwi07g3x79a1rr032m-python3.13-pygments-2.20.0/lib/python3.13/site-packages:/nix/store/rfhv4bxzg6aqv7ll7d2g3fx7vdj63ks3-python3.13-tabulate-0.10.0/lib/python3.13/site-packages:/nix/store/77p6rnrhbc14aaw7iwf6d7vxl89qa9kj-python3.13-click-8.3.1/lib/python3.13/site-packages:/nix/store/8qn7dwv1rh0h80k7w0f9pa798y90vv2y-python3.13-blinker-1.9.0/lib/python3.13/site-packages:/nix/store/vxp23qrd7v308fr6g63cbai6lpxqm13j-python3.13-itsdangerous-2.2.0/lib/python3.13/site-packages:/nix/store/2kwicy8c1ab6zw8p1ps3nnn623b68dn0-python3.13-jinja2-3.1.6/lib/python3.13/site-packages:/nix/store/hmgasx01bmwlz4nr23gm13q9hnqkqw19-python3.13-werkzeug-3.1.6/lib/python3.13/site-packages:/nix/store/jpyvycfsc7gx267kaswq71dawa5ng0vq-python3.13-markupsafe-3.0.3/lib/python3.13/site-packages:/nix/store/r70kacvi02lxf71qmdhqqfjfbbzcr2pc-python3.13-httpx-0.28.1/lib/python3.13/site-packages:/nix/store/7y5zfyjwhqgxil8kq9qqsfbw00rmqzrn-python3.13-anyio-4.13.0/lib/python3.13/site-packages:/nix/store/hqpy59n4gai7vdd2wdzvgax6gjnk83wc-python3.13-certifi-2026.01.04/lib/python3.13/site-packages:/nix/store/hgsr99pnjk2bcjc4z3m0z6a76kgjnlyh-python3.13-httpcore-1.0.9/lib/python3.13/site-packages:/nix/store/ffl6rnq6adprav63d171av3v1a9c4a7x-python3.13-idna-3.11/lib/python3.13/site-packages:/nix/store/yz02xvcmxq8x69vdfhabqls4qpbi2n2h-python3.13-h11-0.16.0/lib/python3.13/site-packages:/nix/store/wlx6zqsn7sx3n005izf63gaigzp2wc1n-python3.13-blog.node5.net-0.1.0/lib/python3.13/site-packages"

Full blog flake:

{
  description = "A basic flake using pyproject.toml project metadata";

  inputs = {
    pyproject-nix = {
      url = "github:nix-community/pyproject.nix";
      inputs.nixpkgs.follows = "nixpkgs";
    };
  };

  outputs = { nixpkgs, pyproject-nix, ... }:
    let
      inherit (nixpkgs) lib;

      project = pyproject-nix.lib.project.loadPyproject {
        # Read & unmarshal pyproject.toml relative to this project root.
        # projectRoot is also used to set `src` for renderers such as buildPythonPackage.
        projectRoot = ./.;
      };

      # This example is only using x86_64-linux
      pkgs = nixpkgs.legacyPackages.x86_64-linux;

      python = pkgs.python3;

      # Returns an attribute set that can be passed to `buildPythonPackage`.
      attrs = project.renderers.buildPythonPackage { inherit python; };

      pkg = python.pkgs.buildPythonPackage (attrs // {
          meta = {
            description = "Blog backend for blog.node5.net";
            homepage = "https://blog.node5.net/Blog%20meta/";
            changelog = "https://git.node5.net/blog/blog.node5.net_flask/log/";
            mainProgram = "blog-node5";
          };
        });
    in
    {
      packages.x86_64-linux.default =
        pkg;
    } // {
    pythonPath = "${python.pkgs.makePythonPath attrs.dependencies}:${pkg}/${python.sitePackages}";
  };
}

We can use it like this:

{ inputs, pkgs, ... }:
let
  user = "blog";
  working_dir = "/var/lib/blog";
  db_location = "${working_dir}/blog.node5.net.db";

  # Combine articles and other blog source files like templates and static files
  content = pkgs.stdenv.mkDerivation {
    pname = "node5-blog-content";
    version = "1.0";

    src = "${inputs.blog-articles}";

    buildPhase = ''
      mkdir -p $out/articles
      cp -a ${inputs.blog-articles}/* $out/articles/

      mkdir -p $out/blog.node5.net
      cp -a ${inputs.node5-blog}/blog.node5.net/* $out/
    '';
  };
in
{
  users.extraUsers.${user} = {
    isSystemUser = true;
    description = "blog service user";
    home  = "/nonexistent";
    shell = "/usr/sbin/nologin";
    group = "${user}";
  };

  # https://nixos.wiki/wiki/Nginx#UNIX_socket_reverse_proxy
  users.groups."${user}".members = [ "nginx" ];
  systemd.services.nginx.serviceConfig.ProtectHome = false;

  # Create project directories
  systemd.tmpfiles.rules = [
    "d /run/blog 0770 blog nginx"
    "d ${working_dir} 0771 blog uwsgi"
  ];

  # init DB if it doesn't exist
  systemd.services."uwsgi".preStart = ''/bin/sh -c '
  if [ ! -f ${db_location} ];
  then
    ${pkgs.sqlite}/bin/sqlite3 ${db_location} < ${inputs.node5-blog}/create_db.sql;
  fi'
  '';

  services.uwsgi = {
    enable = true;
    plugins = [ "python3" ];
    instance = {
      type = "emperor";
      vassals = {
        blog = {
          type = "normal";
          env = [
            "PYTHONPATH=${inputs.node5-blog.pythonPath}"
            "CONTENT_ROOT_PATH=${content}"
          ];
          module = "blog_node5_net:app";
          socket = "/run/blog/blog.sock";
          chdir  = "${working_dir}";  # This is where the SQLite database will be stored
          socketGroup   = "nginx";
          immediate-gid = "nginx";
          chmod-socket  = "770";
          buffer-size   = 32768;
        };
      };
    };
  };

  services.nginx = {
    enable = true;
    virtualHosts."blog.node5.net" = {
      enableACME = true;
      forceSSL   = true;
      locations."/".uwsgiPass = "unix:/run/blog/blog.sock";
    };
  };

}

Minor things to improve, but it works


Firewall rejections are logged

By default nix will log firewall rejections, you'll want to turn this off, to save your SSD

networking.firewall.logRefusedConnections
[523935.720369] refused connection: IN=eno1 OUT= MAC=ec:8e:b5:73:ae:6b:22:55:a4:35:cd:8e:08:00 SRC=193.32.209.238 DST=45.145.93.105 LEN=40 TOS=0x00 PREC=0x20 TTL=56 ID=0 PROTO=TCP SPT=33401 DPT=28901 WINDOW=65535 RES=0x00 SYN URGP=0

Comments


(Will await approval before becoming public)

SELECT id, nickname, comment, page_url, visitor_url, (CASE WHEN show_contact THEN contact ELSE NULL END) as contact_info, created_at
FROM comment WHERE approved AND public AND page_url = '/NixOS on my server' ORDER BY created_at DESC;
*No comments*